<?php

require_once 'htmlMimeMail5/htmlMimeMail5.php';

/**
 * Обработчик ошибок. На продакшен-сервере шлет письма разработчику при
 * отключенном режиме отладки.
 *
 */
class Kernel_ErrorCatcher {

    /**
     * Время в минутах с момента отправки предыдущего сообщения, в течение
     * которого одноименные ошибки группируются.
     *
     */
    const ERROR_GROUP_INTERVAL = 10;

    /**
     * Количество сообщений бэктрейса, которое не нужно показывать (собственно,
     * реализация обработчика ошибок).
     *
     */
    const BACKTRACE_OFFSET = 3;

    /**
     * Длина, до которой нужно обрезать значения строковых аргументов.
     *
     */
    const BACKTRACE_MAX_STRLEN = 32;

    /**
     * Тело почтового сообщения.
     *
     * @var array
     */
    private $_message = array();

    /**
     * Обрабатывает ошибку.
     *
     * @param    integer $errno
     * @param    string  $errstr
     * @param    string  $errfile
     * @param    integer $errline
     */
    public function process($code, $message, $file, $line) {
        // если ошибка попадает под текущий уровень оповещения об ошибках
        if ($code & error_reporting()) {
            $hash = $this->_hash($code, $message, $file, $line);
            $repeated = $this->_isRepeated($hash);
            switch (true) {
                // ошибка произошла впервые
                case false === $repeated:
                    $this->_insert($code, $message, $file, $line, $hash);
                    $this->_mail($code, $message, $file, $line, 1);
                    break;
                // ошибка произошла повторно, и нужно  +1
                case 0 == $repeated:
                    $this->_update($hash, false);
                    break;
                // ошибка произошла повторно, нужно +1 и пора отправлять e-mail
                default:
                    $this->_update($hash, true);
                    $this->_mail($code, $message, $file, $line, $repeated);
                    break;
            }
        }

        // независимо от error_reporting фатальная ошибка должна завершать
        // выполнение приложения
        if ($this->_isFatal($code)) {
            throw new Kernel_Filter_Exception_InternalError($message);
        }
    }

    /**
     * Возвращает хэш ошибки.
     *
     * @return  string
     */
    private function _hash() {
        return md5(serialize(func_get_args()));
    }

    /**
     * Определяет, возникла ли оштбка повторно в течение периода группировки.
     *
     * indeger (кол-во ошибок до этой) — означает что ошибку нужно
     * инкрементировать и отправлять e-mail;
     * 0 — просто инкрементировать;
     * NULL — новая ошибка, создать и отправить на e-mail;
     *
     * @param   string  $hash
     * @return  mixed
     */
    private function _isRepeated($hash) {
        $query = 'SELECT
                         IF(DATE_ADD(`date`, INTERVAL ' . self::ERROR_GROUP_INTERVAL . ' MINUTE) >= NOW(), `count`, 0)
                    FROM
                         `system_error`
                   WHERE
                         `hash` = "' . $hash . '"
                     AND
                         !`sent`
                   LIMIT
                         1';
        return database::fetchOne($query);
    }

    /**
     * Добавляет ошибку в БД.
     *
     * @param   integer $code
     * @param   string  $message
     * @param   string  $file
     * @param   integer $line
     * @param   string  $hash
     */
    private function _insert($code, $message, $file, $line, $hash) {
        database::insert('system_error', array(
            'code' => $code,
            'message' => $message,
            'file' => $file,
            'line' => $line,
            'hash' => $hash,
        ));
    }

    /**
     * Обновляет ошибку в БД.
     *
     * @param   string  $hash
     * @param   boolean $sent
     */
    private function _update($hash, $sent) {
        database::update('system_error', array(
            'count' => new Kernel_Db_Expression('`count` + 1'),
            'sent' => $sent,
        ), array(
            'hash' => $hash,
            'sent' => false,
        ));
    }

    /**
     * Возвращает текстовый префикс ошибки.
     *
     * @param   integer  $errno
     * @return  string
     */
    private function _getPrefix($errno) {
        // определяем тип произошедшей ошибки
        switch ($errno) {
            case E_NOTICE:
            case E_USER_NOTICE:
                $prefix = 'Notice: ';
                break;

            case E_WARNING:
            case E_USER_WARNING:
                $prefix = 'Warning: ';
                break;

            case E_ERROR:
            case E_USER_ERROR:
                $prefix = 'Fatal error: ';
                break;

            case E_STRICT:
                $prefix = 'Strict standarts: ';
                break;

            default:
                $prefix = 'Unknown error';
                break;
        }

        return $prefix;
    }

    /**
     * Возвращает стек вызовов до момента ошибки.
     *
     * @return  array
     */
    private function _getBacktrace() {
        $backtrace = array_slice(debug_backtrace(), self::BACKTRACE_OFFSET);
        $messages = array();
        $i = 0;
        foreach ($backtrace as $entry) {
            $message = ++$i . ') ';
            if (isset($entry['class'])) {
                $message .= $entry['class'];
            }
            if (isset($entry['type']))
            {
                $message .= $entry['type'];
            }
            if (isset($entry['function'])) {
                $message .= $entry['function'];
            }
            $message .= '(';
            if (!empty($entry['args'])) {
                $arguments = array();
                foreach ($entry['args'] as $arg) {
                    $arguments[] = $this->_format($arg);
                }
                $message .= implode(', ', $arguments);
            }
            $message .= ') in ';
            $message .= isset($entry['file']) ? $entry['file'] : '[PHP Kernel]';
            if (isset($entry['line'])) {
                $message .= ':' . $entry['line'];
            }
            $messages[] = $message;
        }
        return $messages;
    }

    /**
     * Форматирует значение в соттветствии с его типом.
     *
     * @param   mixed   $value
     * @return  string
     */
    private function _format($value) {
        switch (gettype($value)) {
            case 'string':
                $string = "'" . (strlen($value) <= self::BACKTRACE_MAX_STRLEN
                    ? $value : substr($value, 0, self::BACKTRACE_MAX_STRLEN) . '...') . "'";
                break;
            case 'object':
                $string = 'Object ' . get_class($value);
                break;
            case 'array':
                $string = 'Array of ' . count($value) . ' elements';
                break;
            case 'resource':
                $string = 'Resource(' . strstr($value, '#') . ')';
                break;
            case 'boolean':
                $string = $value ? 'TRUE' : 'FALSE';
                break;
            case 'NULL':
                $string = 'NULL';
                break;
            default:
                $string = $value;
                break;
        }

        return $string;
    }

    /**
     * Добавляет строку в тело сообщения.
     *
     */
    private function _write() {
        $args = func_get_args();
        $this->_message = array_merge($this->_message, array_values_recursive($args));
    }

    /**
     * Отправляет сообщение.
     *
     * @param   integer $code
     * @param   string  $message
     * @param   string  $file
     * @param   integer $line
     * @param   integer $count
     */
    private function _mail($code, $message, $file, $line, $count) {
        if (count($to = $this->_getTo()) > 0) {
            $this->_message = array();
            // составляем сообщение об ошибке в удобочитаемом виде
            $subject = $this->_getPrefix($code) . $message . ' in ' . $file . ':' . $line;
            $this->_write($subject . ($count > 1 ? ' repeated ' . $count . ' times' : ''), '');
            if (isset($_SERVER['REQUEST_URI'])) {
                $this->_write('Request URI: ' . $_SERVER['REQUEST_URI'], '');
            }
            $this->_write($this->_getBacktrace());
            $mail = new htmlMimeMail5;
            $mail->setFrom($this->_getFrom());
            $mail->setSubject($subject);
            $mail->setText(implode("\n", $this->_message));
            $mail->send($to);
        }
    }

    /**
     * Возвращает значение поля "from" сообщения.
     *
     * @return  string
     */
    private function _getFrom() {
        return 'ErrorCatcher <error-catcher@' . preg_replace('/^www\./', '',  $_SERVER['HTTP_HOST']) . '>';
    }

    /**
     * Возвращает адреса e-mail, на которые нужно отправить сообщение.
     *
     * @return  array
     */
    private function _getTo() {
        return defined('DEBUG_EMAIL')
            ? array_filter(array_map('trim', preg_split('/[,;]/', DEBUG_EMAIL)))
            : array();
    }

    /**
     * Определяет, является ли ошибка фатальной.
     *
     * @param   integer  $errno
     * @return  boolean
     */
    private function _isFatal($errno) {
        return in_array($errno, array(E_ERROR, E_USER_ERROR));
    }
}
